feat(frontdoor): git-push webhook — the fail-closed trigger that deploys - #38
Merged
Conversation
…oys (#37) deploy_flow.on_push is the build->deploy->preview flow, but nothing turned a real `git push` into a deploy. This is that trigger: a governed webhook receiver the git host (Gitea/GitHub) POSTs to on every push. It closes the last named gap in BUILD_DEPLOY.md ("the only remaining piece is the push trigger") — `git push` now literally deploys. It is FAIL-CLOSED at the door: an unsigned or mis-signed push is NEVER built and NEVER deployed. tools/push_webhook.py — a pure, unit-tested core + a thin stdlib http.server wrapper: - verify_signature: constant-time HMAC-SHA256 over the RAW body; accepts GitHub `X-Hub-Signature-256: sha256=…` and Gitea `X-Gitea-Signature: …` (bare hex). Empty secret/body/ header => False. - parse_push_event: normalises the git-host push payload (branch vs tag, delete, changed files). - handle_push: verify FIRST; a bad signature returns `rejected` with NO build started. Tags/deletes are ignored. The full source tree at the pushed SHA is resolved by an injected checkout callback (falls back to the payload's changed files, documented). Emits a SEALED, tamper-evident receipt bound to the exact body digest — the project secret is never sealed, echoed, or written. - serve(): POST /hooks/<tenant>/<app>, per-tenant secret from the sovereign store; 401 rejected / 202 accepted / 422 build-failed. GET only /healthz. Same fail-closed posture as the rest of the stack: the trigger itself is a zero-trust gate, not an open hook — the sovereign answer to an anonymous deploy webhook. Wired: capd/git-push-webhook.mesh.capd.json (caps.dev.git-push-webhook, composes_with git-push-deploy); validate.py (required tool + CapD); Makefile `push-webhook`; portal evidence view surfaces webhook-receipts; docs/BUILD_DEPLOY.md documents the trigger. Tests: +14 = 196 tools tests green (signed deploys, unsigned/forged/body-tampered rejected & never built, tag/delete ignored, resolve_files override, sealed+persisted receipt, secret never leaked).
…shing A push whose HMAC verifies but whose body is a JSON non-object ([], 123, "x", null) reached parse_push_event and would raise on .get() — a 500 on authenticated-but-malformed input. handle_push now rejects it cleanly (same as non-JSON), and parse_push_event accesses nested fields defensively (_obj() + isinstance-guarded commits). +1 test = 197 green. Handle any input; never crash the door.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
deploy_flow.on_pushwas the build→deploy→preview flow, but nothing turned a realgit pushinto a deploy. This adds that trigger: a governed webhook receiver the git host (Gitea/GitHub) POSTs to on every push. It closes the last named gap indocs/BUILD_DEPLOY.md— "the only remaining piece is the push trigger" — sogit pushnow literally deploys.Fail-closed at the door
An unsigned or mis-signed push is NEVER built and NEVER deployed. Every git host signs deliveries (GitHub
X-Hub-Signature-256: sha256=…, GiteaX-Gitea-Signature: …); we verify with a constant-time HMAC-SHA256 over the raw body against the project's per-tenant secret before any work starts. The trigger itself is a zero-trust gate, not an open hook.Design
tools/push_webhook.py— a pure, unit-tested core (verify_signature/parse_push_event/handle_push) + a thin stdlibhttp.serverwrapper. No dependencies.resolve_files(repo, ref, after)callback (production: a checkout of the pushed SHA); it falls back to the payload's changed files, documented.Wired in
capd/git-push-webhook.mesh.capd.json(caps.dev.git-push-webhook, composes_withgit-push-deploy)validate.py(required tool + CapD),Makefilepush-webhookwebhook-receiptsdocs/BUILD_DEPLOY.mddocuments the triggerTests
+14 = 196 tools tests green. Signed push deploys; unsigned / forged-signature / body-tampered-after-signing all rejected and never built; tag & branch-delete ignored;
resolve_filesoverride detects the real buildpack; receipt sealed + persisted + body-bound; secret never leaked into decision or receipt.🤖 Generated with Claude Code